Refactor Core Architecture & Eliminate Polling#522
Open
SuperCoolPencil wants to merge 56 commits into
Open
Conversation
…rker notification
…oduce event-driven orchestrator components
…d ProgressAggregator components
…tatic-analysis linting for Unicode literals
…te fields for improved type consistency
…and implementing a bitmask-based tracker
Binary Size Analysis
|
…lidation using master list data
…d resume event publish
…m Add and AddWithID service methods
…tenv while cleaning up orchestrator test constants
…d update event handling logic
… filename collision exhaustion
…r wait threshold for 429 concurrency tests
…ertions, and implement synchronous download persistence
…aster list versioning
…assertion robustness in test suite
…event race conditions during initialization
…ic path remapping tests
…d persistence by checking for existence first
- cmd/rm.go: fix errcheck lint violations by wrapping resp.Body.Close()
in anonymous funcs so the error is explicitly discarded
- internal/utils/debug.go: add CloseDebug() to close the open log file
handle and reset sync.Once; protect Debug() writes with debugMu so
CloseDebug() and Debug() are race-safe
- cmd/test_env_test.go: call utils.CloseDebug() in setupXDGEnvIsolation
cleanup so Windows can remove the temp dir (the open debug log file
was causing 'file in use' errors during t.TempDir() cleanup)
- cmd/cli_test.go: change net.Listen('tcp', ...) to 'tcp4' and replace
t.Fatalf with t.Skipf on bind failure; mirrors the existing
testutil.NewHTTPServerT behavior for runners where the dual-stack
socket provider is unavailable
- cmd/get_test.go: replace httptest.NewServer (which tries IPv6 first
and panics) with testutil.NewHTTPServerT in startAuthedTestServer;
add testutil import, remove unused httptest import
…ess test TestHandleDownload_HeadlessMode_AutoApprovesNonDuplicate used httptest.NewServer for the probe server, which tries to bind an IPv6 listener first and panics on Windows CI runners where the dual-stack socket service provider is unavailable. Replace with testutil.NewHTTPServerT (tcp4, skips on bind failure) to match the pattern used by the other tests fixed in the previous commit.
…HTTPServerT Four more bare httptest.NewServer calls were using Go's newLocalListener() which tries tcp6 [::1]:0 first and panics on Windows CI runners lacking the dual-stack socket provider: - cmd/http_api_test.go:259 TestEventsEndpoint_RequiresAuthAndStreamsSSE - cmd/http_api_test.go:619 TestExecuteAPIAction_SendsIDAsQueryParam - cmd/http_handler_test.go:300 (probe server for download handler test) - cmd/http_handler_test.go:435 (probe server for download handler test) Replace all with testutil.NewHTTPServerT (binds tcp4, skips gracefully on failure). Add testutil import to both files.
In a recent commit, the \Start\ method of \program\ was updated to forcibly set \os.Args\ to \server start\ to ensure the daemon mode starts correctly from the service manager. However, this means \TestProgramLifecycle\ and \TestProgramContextCancellation\ now actually run the background server logic during tests instead of executing a harmless \--help\ run. This surfaces the same dual-stack \ cp\ provider error on \windows-latest\ as the HTTP API tests when the server attempts to bind to a port, leading to \could not find available port\. Add a pre-check to gracefully skip both tests if the \ cp\ socket provider fails to initialize, matching the skip pattern established in \cli_test.go\ and \get_test.go\.
…annel synchronization in scheduler
…ng and channel draining
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Architecture Refactor: High Level Design
This document visually illustrates the recent structural changes to the Surge architecture, moving from a tightly coupled monolithic engine to a layered, domain-driven design.
The Old Architecture (Monolithic)
In the previous design, the
internal/enginepackage acted as a catch-all monolith. The UI and Core tightly coupled to the engine's internal workings, and state was updated via direct callbacks and polling.graph TD A[TUI / CLI] --> B[internal/core] B --> C[internal/processing] C --> D((internal/engine)) subgraph Monolithic Engine D --> E[engine/concurrent] D --> F[engine/single] D --> G[engine/transport] D --> H[engine/state] D --> I[engine/types/progress.go] end E -.-> |Hooks / Polling| C F -.-> |Hooks / Polling| CThe New Architecture (Decoupled & Layered)
The new architecture introduces strict layering and unidirectional data flow. Components are isolated, and the monolithic
enginehas been decomposed intostrategy,scheduler,orchestrator,progress, andstore. TheEventBusreplaces direct callback hooks, andServiceprovides a clean API boundary.graph TD UI[TUI / CLI] --> API[Service Layer<br>internal/service] API --> ORCH[Orchestrator Layer<br>internal/orchestrator] subgraph Core Domain ORCH --> SCHED[Scheduler Layer<br>internal/scheduler] SCHED --> STRAT[Strategy Layer<br>internal/strategy] STRAT --> C[concurrent] STRAT --> S[single] STRAT --> PROG[Progress Tracking<br>internal/progress] STRAT --> TRANS[Transport / Network<br>internal/transport] end STRAT -.-> |Publishes Typed Events| BUS[EventBus] PROG -.-> |Publishes Stats| BUS BUS -.-> |Subscribed Stream| API API -.-> |Real-time Updates| UI ORCH --> STORE[State / DB Layer<br>internal/store]Download Lifecycle Flow
Here is how a download request flows through the new system:
sequenceDiagram participant U as User (UI/CLI) participant S as Service participant O as Orchestrator participant SCH as Scheduler participant W as Strategy (Worker) participant B as EventBus U->>S: Add(url) S->>O: Enqueue Download O->>B: Publish(State: Queued) O->>SCH: Submit Task SCH->>W: Execute(Strategy) W->>B: Publish(State: Downloading) loop During Download W->>W: Fetch Chunks W->>B: Publish(ProgressEvent) end W->>SCH: Complete/Error W->>B: Publish(State: Completed) SCH->>O: Task DoneGreptile Summary
This PR replaces the monolithic
internal/enginepackage with a layered, domain-driven architecture:internal/strategy(download algorithms),internal/scheduler(task lifecycle),internal/orchestrator(coordination + EventBus),internal/progress(progress tracking),internal/store(state persistence), andinternal/service(public API). Polling and direct callback hooks are eliminated in favour of a typedEventBusand aProgressAggregator.EventBuscleanly separates publishers from subscribers;Shutdown()drains the buffered input channel before closing listener channels, fixing the previous shutdown-ordering bug.enqueueResolvednow persists the master-list entry beforepool.Add, eliminating theEventStarted/EventQueuedstatus-overwrite race.stateProgressin the TUI correctly narrowsmsg.Statethrough*types.DownloadRecord→progress.CfgProgress, fixing the always-nil chunk-map rendering.Confidence Score: 4/5
The core refactor is solid and the most critical shutdown, persistence, and type-assertion bugs from the previous round are all addressed; the sheer breadth of 154 changed files leaves some residual uncertainty about untested edge paths.
Every previously flagged defect that was reproducible from the diff has been corrected: the EventBus drains correctly on shutdown, the master-list entry is persisted before pool.Add eliminating the EventQueued/EventStarted race, getDetailPath takes an explicit dir argument closing the baseDir data-race, buildResumeConfig carries the full task/bitmap state, ResumeBatch no longer early-exits on a single corrupt gob, and stateProgress correctly narrows through *DownloadRecord. The remaining concern is the scale of the change — 154 files across six new packages — which makes it difficult to have full confidence that every interaction has been exercised by the existing tests.
internal/scheduler/scheduler.go (safeSendProgress dead else-branch), internal/orchestrator/progress.go (ProgressAggregator shutdown ordering relative to pool), and the broader interaction between the new strategy/concurrent and strategy/single packages and the updated scheduler worker loop.
Important Files Changed
Comments Outside Diff (12)
internal/orchestrator/pause_resume.go, line 211-232 (link)ResumeBatchcold path fails for downloads without a detail state filestore.LoadStates(coldIDs)only reads fromdetails/<id>.gobfiles written bySaveStateWithOptions(i.e., downloads that have been through at least oneEventPausedcycle). Queued downloads that never started, and errored downloads that never saved a pause snapshot, have no detail file. For these IDsstates[id]will be absent, andResumeBatchreturns"download not found or completed".In contrast, the single
Resumepath callsstore.GetDownload(id)first and succeeds even whenLoadStatefails, becausebuildResumeConfigaccepts anilsavedStateand falls back to the master-listentry. The batch path should do the same: supplementLoadStateswith a batch master-list lookup and fall through tobuildResumeConfig(id, outputPath, entry, nil, settings)for IDs not present in the detail map.Prompt To Fix With AI
internal/orchestrator/pause_resume.go, line 246-252 (link)savedStateis nil when a download lives only in the master list (queued but never started or saved a pause snapshot). At this pointbuildResumeConfigsucceeds because it falls back toentry, but the immediately followingsavedState.Filenamedereference panics. AnyResumeBatchcall that includes a download that was queued and never executed (e.g., the app was killed before the scheduler ran it) will crash the process.Prompt To Fix With AI
internal/orchestrator/manager.go, line 331-342 (link)EventQueuedmeans download is lost on restartPublishreturnscontext.DeadlineExceededafter 1 second ifInputChis full; calling code discards the error with_ =. When this happens the download is already in the scheduler pool butStartEventWorkernever seesEventQueued, sostore.AddToMasterListis never called. The in-memory entry disappears on the next restart and the user has no indication the download was lost. The pool is also cleared byGracefulShutdownbefore state is written, so there's no recovery path.InputChcan fill up whenbroadcastLoopis delayed by slow subscribers (each non-progress event waits up to 1 s per subscriber inbroadcastMsg), making this reachable without a crash — a single lagging TUI or HTTP SSE client is sufficient.Consider logging or surfacing a non-nil
Publisherror here, or using a directstore.AddToMasterListcall as a synchronous fallback before the event is dispatched.Prompt To Fix With AI
internal/scheduler/manager.go, line 69-71 (link)uniqueFilePathsilently returns the originalpath— which is known to conflict (the existence check at the top of the function confirmed it). Any caller expecting a non-conflicting path from this function would silently overwrite an existing file. The fallback should either return an error-sentinel empty string or append a timestamp/random suffix to guarantee a unique result.Prompt To Fix With AI
cmd/root.go, line 114-124 (link)ProgressStatewill panic on unexpected typescfg.ProgressState.(*progress.DownloadProgress)is used without the comma-ok form in two places here. BecauseProgressStateis typed asinterface{}, any value that is not*progress.DownloadProgresstriggers a panic insidebuildActiveDownloadChecker. The rest of the codebase consistently usesprogress.CfgProgress(&cfg)for this narrowing, which handles nil and wrong-type gracefully — the new code should use the same helper.Prompt To Fix With AI
internal/store/db.go, line 31-35 (link)configuredandbaseDirread without a lock inensureDirsConfigureandCloseDBwriteconfiguredandbaseDirundermasterMu, butensureDirsreads both without holding any lock. WhenSaveStateWithOptionscallsensureDirsbefore acquiringmasterMu, a concurrentConfigureorCloseDBcall races with these reads. The Go race detector will flag this. HoldingmasterMu.RLock()for the read-only check at the top ofensureDirswould close the race.Prompt To Fix With AI
internal/store/db.go, line 43-48 (link)cleanupOrphans(baseDir)reads package variable without a lockThe PR applied the previous review's suggestion by reading
configuredandbaseDirundermasterMu.RLock()and snapshotting them intoisConfigured/dir— but thecleanupOnce.Doclosure at line 45 reads the package variablebaseDirdirectly instead of the captureddir. This is still a concurrent read without the lock.Race scenario: Thread A releases
masterMu.RUnlock()withdir="/path1", Thread B callsCloseDB(setsbaseDir="") thenConfigurewith/path2(setsbaseDir="/path2"), Thread A enterscleanupOnce.Doand callscleanupOrphans("/path2")— cleaning temp files from an unrelated base directory. On Linux, ifbaseDiris""when the closure fires,os.ReadDir("")traverses the current working directory and can delete any.tmp-*files found there.Prompt To Fix With AI
internal/orchestrator/manager.go, line 312-362 (link)dispatchToSchedulerat line 312 callsmgr.pool.Add(cfg), which may immediately schedule a pool worker. That worker callssafeSendProgress(cfg.ProgressCh, EventStarted)— writing directly toInputCh— beforeenqueueResolvedreaches thestore.AddToMasterListcall at line 342 or thePublish(EventQueued)call at line 358. ThebroadcastLoopthen serialises them forStartEventWorkerin arrival order:EventStartedis processed first (sets status"downloading"), thenEventQueuedis processed second (unconditionalAddToMasterListwithStatus: "queued"atevents.go:331), overwriting the correct status. Any crash between theEventQueuedwrite and the next lifecycle event (EventComplete/EventPaused/EventError) leaves the DB entry as"queued"for a download that had already started, preventing correct crash-recovery resume.Prompt To Fix With AI
internal/store/state.go, line 102-113 (link)getDetailPath(state.ID)at line 106 reads the package-levelbaseDirvariable without holdingmasterMu. Between theensureDirs()call (which acquires and releasesmasterMu.RLock()) and themasterMu.Lock()at line 112, a concurrentCloseDB()orConfigure()can modifybaseDir. IfCloseDBfires in that window,getDetailPathcomputes a path rooted at"", andatomicWritecreates or reads a file relative to the current working directory. The Go race detector will flag this. The fix is to hold the master lock for the entire write sequence.Prompt To Fix With AI
internal/store/state.go, line 254-273 (link)getDetailPathreadsbaseDirwithout holdingmasterMuinLoadStatesLoadStatesis called entirely outside any lock. Inside the loop,getDetailPath(id)reads the package-levelbaseDirvariable without any synchronization. If a concurrentCloseDB()fires between two iterations,baseDirbecomes""andgetDetailPathreturns"details/<id>.gob"— a relative path resolved against the current working directory. Any.gobfile found there would be silently decoded and returned as valid resume state, corrupting the batch-resume result.The same race exists in
LoadStateat line 243 (loadGob(getDetailPath(foundID), &ds)), which executes afterLoadMasterList()has released itsRLock. Both functions should snapshotbaseDirunder the read-lock before constructing the path, or delegate path construction to a caller-provideddirargument the wayensureDirsInternaldoes.Prompt To Fix With AI
internal/store/state.go, line 106 (link)getDetailPathreadsbaseDirwithoutmasterMuin three call sitesensureDirs()at line 102 acquires and releasesmasterMu.RLock()and then returns. At line 106,getDetailPath(state.ID)reads the package-levelbaseDirvariable again — this time without any lock. A concurrentCloseDB()orConfigure()call between the two reads can changebaseDir, causingatomicWriteto create or read a.gobfile relative to an unintended directory (or the current working directory whenbaseDiris"").The same race exists in
LoadState(callsgetDetailPathafterLoadMasterList()releases itsRLock) and inLoadStates(callsgetDetailPathin a loop, entirely lock-free). The Go race detector will flag all three.Fix: snapshot
baseDirundermasterMu.RLock()(or hold it) before callinggetDetailPath, as already done inensureDirsandDeleteState.Prompt To Fix With AI
internal/scheduler/scheduler.go, line 638-662 (link)safeSendProgressblocks indefinitely during shutdown ifInputChis fullsafeSendProgressis a raw blocking send with no timeout or context. When a single-threaded download is paused duringGracefulShutdown, the worker callssafeSendProgress(localCfg.ProgressCh, ...)before callingp.wg.Done(). IfeventBus.InputCh(capacity 100) is completely full at that moment — possible when many downloads are pausing simultaneously while the broadcast loop is delayed by slow subscribers (each non-progress event waits up to 1 second per subscriber inbroadcastMsg) — the worker goroutine blocks here indefinitely.pool.wg.Wait()never returns, andGracefulShutdownhangs forever.The concurrent downloader avoids this by sending
EventPausedbeforeRunDownloadreturns nil, so the blocking path only affects single-threaded downloads. Consider replacing the raw send with aselectthat also reads from the existingprogressDonechannel (already used for shutdown signalling in the scheduler), so the worker can bail out if the bus is unresponsive.Prompt To Fix With AI
Reviews (18): Last reviewed commit: "refactor: ensure safe event bus shutdown..." | Re-trigger Greptile